The RateLimiter class implements a Sliding Window Log algorithm to enforce configurable request limits per time window. This is critical for Steam Web API compliance and preventing account bans.
API Safety: Steam’s Community Market API enforces strict rate limits.Exceeding these limits can result in:
429 Too Many Requests errors
Temporary IP bans
Permanent account restrictions
The rate limiter guarantees compliance when shared across all schedulers.
Every API call must acquire a token before proceeding:
RateLimiter.py:24-49
async def acquire_token(self) -> None: """ Acquire a token to make a request, waiting if necessary to respect rate limits. This method ensures that no more than max_requests occur within any window. If the limit is reached, it waits until a slot becomes available. """ while True: async with self._lock: current_time = time.time() cutoff_time = current_time - self._window_seconds # Remove expired timestamps (older than window) self._timestamps = [ts for ts in self._timestamps if ts > cutoff_time] # Check if we've hit the rate limit if len(self._timestamps) >= self._max_requests: # Calculate exact wait time until oldest timestamp exits the window oldest_timestamp = self._timestamps[0] wait_time = self._window_seconds - (current_time - oldest_timestamp) else: # We have capacity - grant the token self._timestamps.append(time.time()) return # Lock is automatically released here when exiting the context manager # Wait outside the critical section await asyncio.sleep(wait_time)
After 15 seconds, the oldest request will age out of the window.
4
Grant Token or Sleep
if capacity_available: self._timestamps.append(time.time()) # Record this request return # Grant token immediatelyelse: await asyncio.sleep(wait_time) # Wait outside the lock
Key insight: Sleep happens outside the lock to avoid blocking other coroutines.
The caller (scheduler) is completely unaware of rate limiting delays.From the scheduler’s perspective, the API call just takes longer when rate-limited. This separation of concerns keeps scheduling logic simple.
async with self._lock: # Only ONE coroutine can execute this block at a time self._timestamps = [ts for ts in self._timestamps if ts > cutoff_time]
Without the lock, two coroutines could:
Both read len(self._timestamps) == 14 (below limit of 15)
Both append a timestamp
Result: 16 timestamps (exceeds limit!)
Sleep happens outside the lock
async with self._lock: # Calculate wait_time wait_time = ...# Lock released here# Sleep OUTSIDE the lockawait asyncio.sleep(wait_time)
If we slept inside the lock, all other coroutines would block during the sleep. This would serialize requests instead of allowing concurrent processing.
List comprehension is atomic
self._timestamps = [ts for ts in self._timestamps if ts > cutoff_time]
This creates a new list and assigns it atomically. No risk of partial updates.
Acquire token (capacity available): O(n) where n = requests in window
Acquire token (rate limited): O(n) + sleep time
Cleanup: O(n) list comprehension
In practice, n is small (typically 15-200), so performance is excellent.
Space Complexity
O(max_requests) - stores at most max_requests timestamps.Example: 200 req limit = 200 floats × 8 bytes = 1.6 KB of memory.
Lock Contention
Under high load (many schedulers), coroutines queue at the lock.The critical section is very short (~1ms), so contention is minimal even with 10+ concurrent schedulers.
import asyncioimport timefrom src.RateLimiter import RateLimiterasync def test_rate_limiting(): limiter = RateLimiter(max_requests=3, window_seconds=5.0) print("Requesting 5 tokens (limit is 3 per 5 seconds)...") for i in range(5): start = time.time() await limiter.acquire_token() elapsed = time.time() - start print(f"Token {i+1} acquired after {elapsed:.2f}s")if __name__ == "__main__": asyncio.run(test_rate_limiting())
Expected output:
Requesting 5 tokens (limit is 3 per 5 seconds)...Token 1 acquired after 0.00s # InstantToken 2 acquired after 0.00s # InstantToken 3 acquired after 0.00s # InstantToken 4 acquired after 5.00s # Waited for token 1 to expireToken 5 acquired after 5.00s # Waited for token 2 to expire